Skip to content

[Benchmark] Add asyncio-based multi_turn benchmark v2 to fix deadlock - #53075

Open
Potterluo wants to merge 1 commit into
vllm-project:mainfrom
Potterluo:bench/multi-turn-asyncio-v2
Open

[Benchmark] Add asyncio-based multi_turn benchmark v2 to fix deadlock#53075
Potterluo wants to merge 1 commit into
vllm-project:mainfrom
Potterluo:bench/multi-turn-asyncio-v2

Conversation

@Potterluo

Copy link
Copy Markdown

Purpose

Fixes #42226.

The original benchmark_serving_multi_turn.py deadlocks when mp.Queue.join_thread() waits for a feeder thread that cannot flush a full OS pipe (~1 MB on Linux). mp.Queue.empty() is documented as unreliable, so the queue drain loop exits prematurely, leaving data in the pipe that join_thread() then waits for indefinitely. The deadlock is deterministic when conversation data is large enough (30-40 turns, ~30-80 KB per conversation pickled) and intermittent with smaller conversations.

This PR adds benchmark_serving_multi_turn_v2.py (new file, original unchanged) that replaces the multiprocessing architecture with a single-process asyncio event loop, eliminating all mp.Queue usage.

Why not patch the existing file?

PR #42327 (CLOSED) attempted a conservative patch (get_nowait + cancel_join_thread). This only fixes the join_thread() deadlock but leaves other deadlock paths:

  1. task_queue.get() — blocking call with no timeout; clients stuck here cannot check stop_event
  2. conv_queue.get() — main process blocks indefinitely if a client crashes without sending TERM_SIGNAL
  3. result_queue pipe buffer backpressure — clients block on put() when the pipe is full and the main process isn't draining fast enough

The asyncio rewrite eliminates all of these by design:

  • asyncio.wait_for(task_queue.get(), timeout=1.0) + stop_event check
  • asyncio.wait(client_tasks, timeout=5.0) monitoring loop detects task.exception()
  • Shared in-process list/dict (no pipe, no backpressure)
  • --benchmark-timeout-sec global safety net

Additional features

  • --generate-only: generate dataset to JSON without running the benchmark (generate once, reuse for multiple runs)
  • Shared aiohttp.ClientSession with TCPConnector (pattern from vllm/benchmarks/serve.py)
  • Per-client RNG instances (avoids shared global state in asyncio)

Test Plan

Tested on 2× Ascend 910B3 NPU with Qwen3-0.6B (--data-parallel-size 2), VLLM_SERVER_DEV_MODE=1, --enable-prefix-caching. Cache cleared between tests via POST /reset_prefix_cache.

Linter commands:

python -m ruff check benchmarks/multi_turn/benchmark_serving_multi_turn_v2.py
python -m ruff format --check benchmarks/multi_turn/benchmark_serving_multi_turn_v2.py

Both pass.

Test phases:

  1. Low concurrency performance comparison (v1 vs v2) — 30 conversations, 8-10 turns, --no-early-stop
  2. Deadlock detection — 2000 conversations, 30-40 turns, --max-num-requests 200
  3. v2 upper limit — up to 2000 concurrent clients + sustained load

Test Result

Phase 1: Low concurrency (small conversations — both pass)

Clients Version Samples RPS TTFT mean TPOT mean Latency mean
2 v1 120 1.76 160.6ms 9.6ms 1107ms
2 v2 120 2.09 58.6ms 8.8ms 931ms
4 v1 120 3.79 77.6ms 9.2ms 990ms
4 v2 120 3.63 89.2ms 9.2ms 1003ms

At 4 clients, performance is within normal variance. v1's higher mean TTFT at 2 clients is from multiprocessing startup overhead (p90 is comparable: 86.6 vs 78.2ms).

Phase 2: Deadlock detection (large conversations)

Clients v1 v2
2 HANG (exit 124) Pass
4 HANG (exit 124) Pass
8 HANG (exit 124) Pass
10 HANG (exit 124) Pass
100 HANG (exit 124) Pass

v1 deadlocks at all concurrency levels (2-100c) with 30-40 turn conversations. v2 completes at all levels.

Phase 3: v2 upper limit

Clients Samples RPS TTFT mean Status
200 200 18.10 1,313ms Pass
500 970 18.77 2,816ms Pass
1000 1,750 18.81 18,655ms Pass
2000 3,558 18.85 54,558ms Pass

v2 did not crash at 2000 concurrent clients. Peak throughput ~19 RPS. Server saturation is the bottleneck, not the client.

Sustained load (100c x 4000 requests, 50c x full 2000 conversations): ran 9+ minutes, graceful timeout exit, no memory leaks.


This PR was prepared with AI assistance (GLM5.2 + ZCode). All code was reviewed and all tests were run by the human submitter.

Add benchmark_serving_multi_turn_v2.py as a new file (original
benchmark_serving_multi_turn.py remains unchanged) that replaces the
multiprocessing architecture with a single-process asyncio event loop.

The original tool deadlocks when mp.Queue.join_thread() waits for a
feeder thread that cannot flush a full OS pipe (~1MB on Linux).
mp.Queue.empty() is documented as unreliable, so the drain loop exits
prematurely, leaving data in the pipe that join_thread() then waits
for indefinitely.  This is GitHub issue vllm-project#42226.

The deadlock is deterministic when conversation data is large enough
(30-40 turns, ~30-80KB per conversation pickled) and intermittent
with smaller conversations.

v2 eliminates all mp.Queue usage:
- mp.Process  -> asyncio.create_task
- mp.Queue    -> asyncio.Queue (task) + shared in-process list/dict
- mp.Event    -> asyncio.Event
- task_queue.get() has a 1s timeout so stop_event is always checked
- --benchmark-timeout-sec provides a global safety net
- --generate-only saves a dataset to JSON without running the benchmark
- Shared aiohttp.ClientSession with TCPConnector (pattern from
  vllm/benchmarks/serve.py)

Tested on Ascend 910B3 with Qwen3-0.6B (2-card DP):
- v1 deadlocks at all concurrency levels (2-100c) with 30-40 turn convs
- v2 completes at all levels (2-2000c) with zero deadlocks
- v2 peak throughput: ~19 RPS, max tested: 2000 concurrent clients
- v2 sustained load: 9+ min, no memory leaks

Not duplicating PR vllm-project#42327 (CLOSED): that was a conservative patch
(get_nowait + cancel_join_thread). This is a full architecture rewrite
that eliminates all deadlock paths.

Co-authored-by: GLM5.2
Co-authored-by: ZCode
Signed-off-by: keriko <keriko@users.noreply.github.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@github-actions

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment /ci run for upstream CI or /amd-ci run for AMD CI only whenever CI signals are needed.

Once the PR is approved or has the ready label, the PR author can also use the corresponding /ci run, /ci retry, and /ci cancel commands, or their /amd-ci variants. New commits do not start upstream CI automatically.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

@mergify mergify Bot added the performance Performance-related issues label Aug 20, 2026
@Potterluo

Copy link
Copy Markdown
Author

Design Rationale: Why a new file instead of patching the original?

I considered two approaches before submitting this PR:

Option A (this PR): New benchmark_serving_multi_turn_v2.py file

Why I chose this:

  1. Zero risk to existing users — the original file is unchanged, so anyone currently using benchmark_serving_multi_turn.py is unaffected
  2. Eliminates all deadlock paths, not just join_thread() — while join_thread() is the most commonly observed deadlock, the mp.Queue architecture has other failure modes (blocking get() with no timeout, empty() unreliability, pipe buffer backpressure on put()). The asyncio rewrite addresses all of them by design
  3. Follows an existing patternvllm/benchmarks/serve.py already uses a pure-asyncio approach (shared aiohttp.ClientSession, asyncio.Semaphore, asyncio.create_task). v2 brings the multi-turn benchmark in line with that pattern
  4. Additional features--generate-only (separate dataset generation from benchmarking) and --benchmark-timeout-sec (global safety net) are easier to add in the asyncio model

Downside: Two files to maintain. I understand this is a concern.

Option B (alternative if maintainers prefer): Minimal patch to the existing file

If the maintainers prefer a direct patch to benchmark_serving_multi_turn.py, I can close this PR and submit a smaller one with these targeted fixes (~30-40 lines changed):

# 1. Replace empty()-based drain with get_nowait() (fixes unreliable empty())
import queue
while True:
    try:
        task_queue.get_nowait()
    except queue.Empty:
        break

# 2. Replace join_thread() with cancel_join_thread() (fixes feeder deadlock)
task_queue.cancel_join_thread()
result_queue.cancel_join_thread()
conv_queue.cancel_join_thread()
# (no need to call close() — cancel_join_thread() handles it)

# 3. Add timeout to task_queue.get() in client_main so stop_event is checked
conv_id, messages = task_queue.get(timeout=1)  # was: task_queue.get() (blocking)

This approach is similar to the closed PR #42327 but adds the task_queue.get(timeout=) fix which #42327 did not include.

My recommendation

I'm open to either approach. If Option B is preferred, I'll close this PR and submit the patch. Please let me know which direction the maintainers would like to go.


This assessment was prepared with AI assistance (GLM5.2 + ZCode). Reviewed by the human submitter.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance Performance-related issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: benchmark_serving_multi_turn.py deadlocks after clients exit when --max-num-requests is used

1 participant